Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions scripts/dev-startup.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,66 @@ import { join } from 'node:path';
import test from 'node:test';
import { resolveDevServerPorts } from './dev-server-ports.mjs';
import { prepareSherpaDev } from './prepare-sherpa-dev.mjs';
import { createRequire } from 'node:module';

const require = createRequire(import.meta.url);
const { resolveCommandInvocation, runCommandPrefixed } = require('./dev.cjs');

test('desktop preparation invokes pnpm without the Windows batch shim', () => {
const invocation = resolveCommandInvocation(
'pnpm',
['run', 'plugin-host:prepare'],
{ npm_execpath: 'E:\\repo\\node_modules\\pnpm\\bin\\pnpm.cjs' },
'win32',
);

assert.equal(invocation.cmd, process.execPath);
assert.deepEqual(invocation.args, [
'E:\\repo\\node_modules\\pnpm\\bin\\pnpm.cjs',
'run',
'plugin-host:prepare',
]);
assert.equal(invocation.shell, false);
});

test('direct launcher supports spaces and shell metacharacters and preserves exit failures', async () => {
const root = mkdtempSync(join(tmpdir(), 'dev launcher & spaces '));
const script = join(root, 'fake pnpm.cjs');
const argument = 'literal & value %PATH% "quoted"';
writeFileSync(script, `
if (process.argv[2] !== ${JSON.stringify(argument)}) process.exit(9);
process.exit(Number(process.argv[3]));
`);
// Keep fixtures in the configured temporary root for post-test inspection.
const command = process.platform === 'win32' ? 'pnpm' : 'node';
const args = process.platform === 'win32' ? [argument] : [script, argument];
const env = { npm_execpath: script };
const results = await Promise.all([0, 7].map(code =>
runCommandPrefixed('fixture', command, [...args, String(code)], root, env),
));
assert.equal(results[0].ok, true);
assert.equal(results[1].ok, false);
assert.equal(results[1].code, 7);
});

test('Windows direct launches without npm_execpath use the installed pnpm entry', () => {
const invocation = resolveCommandInvocation('pnpm', ['--version'], {}, 'win32');
assert.match(invocation.args[0], /node_modules[\\/]pnpm[\\/]bin[\\/]pnpm\.cjs$/);
assert.equal(invocation.shell, false);
for (const platform of ['linux', 'darwin']) {
assert.deepEqual(resolveCommandInvocation('pnpm', ['--version'], {}, platform), {
cmd: 'pnpm', args: ['--version'], shell: false,
});
}
});

test('workspace installation finishes before parallel dependency consumers start', () => {
const source = readFileSync(new URL('./dev.cjs', import.meta.url), 'utf8');
const install = source.indexOf("await runCommandPrefixed('mobile-web'");
const consumers = source.indexOf('const prepTasks = [');
assert.ok(install > 0 && install < consumers);
assert.equal(source.match(/runCommandPrefixed\('mobile-web'/g).length, 1);
});

test('HTTP and HMR ports share one contract and reject conflicting overrides', () => {
assert.deepEqual(resolveDevServerPorts({}), { port: 1422, hmrPort: 1421 });
Expand Down
63 changes: 44 additions & 19 deletions scripts/dev.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -144,22 +144,38 @@ function spawnCommand(cmd, args, cwd = ROOT_DIR, envOverrides = {}, shell = fals
});
}

function resolveCommandInvocation(cmd, args, env = process.env, platform = process.platform) {
// Avoid the Windows batch shim, which workspace installs may rewrite while
// preparation is running. Keep paths and arguments out of shell parsing.
if (platform === 'win32' && cmd === 'pnpm') {
const entry = env.npm_execpath && /\.(?:c?js|mjs)$/i.test(env.npm_execpath)
? env.npm_execpath
: path.join(ROOT_DIR, 'node_modules', 'pnpm', 'bin', 'pnpm.cjs');
return {
cmd: process.execPath,
args: [entry, ...args],
shell: false,
};
}

return { cmd: cmd === 'node' ? process.execPath : cmd, args, shell: false };
}

/**
* Run a command asynchronously with piped output, prefixing every line so
* parallel preparation steps stay distinguishable. Resolves (never rejects)
* with { ok, code, error }.
*/
function runCommandPrefixed(prefix, cmd, args, cwd = ROOT_DIR, envOverrides = {}) {
return new Promise((resolve) => {
const child = spawn(cmd, args, {
const env = { ...process.env, ...envOverrides };
const invocation = resolveCommandInvocation(cmd, args, env);
const child = spawn(invocation.cmd, invocation.args, {
cwd,
shell: process.platform === 'win32',
shell: invocation.shell,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
...envOverrides,
},
env,
});

const forward = (stream, out) => {
Expand Down Expand Up @@ -613,20 +629,29 @@ async function main() {
const totalSteps = 2;
let currentStep = 1;

// Step 1: Run all independent preparation tasks in parallel.
// copy-monaco / generate-version / mobile-web / flashgrep / plugin-host have no
// dependencies on each other; each task's output is line-prefixed so the
// interleaved logs stay attributable. The DeepSeek bridge is not prepared
// mobile-web may install workspace dependencies and rewrite node_modules/.bin.
// Finish it before launching consumers of those shims and dependencies.
// The remaining preparations can run in parallel with prefixed output.
// The DeepSeek bridge is not prepared
// here: it is not a compile-time Tauri resource. Official desktop:build
// compiles it; local DeepSeek sessions run `pnpm run prepare:dsh-profile`.
printStep(
currentStep++,
totalSteps,
desktopMode
? 'Prepare resources (parallel: monaco, version, mobile-web, flashgrep, plugin-host)'
? 'Prepare resources (mobile-web first; then parallel: monaco, version, flashgrep, plugin-host)'
: 'Prepare resources (parallel: monaco, version)'
);

if (desktopMode) {
const result = await runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']);
if (!result.ok) {
printError('Build mobile-web failed');
if (result.error?.message) printError(result.error.message);
process.exit(1);
}
}

const prepTasks = [
{
name: 'Copy Monaco resources',
Expand Down Expand Up @@ -675,10 +700,6 @@ async function main() {
}
})(),
});
prepTasks.push({
name: 'Build mobile-web',
promise: runCommandPrefixed('mobile-web', 'node', ['scripts/mobile-web-build.cjs', '--install']),
});
}

const prepResults = await Promise.all(prepTasks.map((task) => task.promise));
Expand Down Expand Up @@ -795,7 +816,11 @@ async function main() {
}
}

main().catch((error) => {
printError('Startup failed: ' + error.message);
process.exit(1);
});
if (require.main === module) {
main().catch((error) => {
printError('Startup failed: ' + error.message);
process.exit(1);
});
}

module.exports = { resolveCommandInvocation, runCommandPrefixed };
2 changes: 1 addition & 1 deletion scripts/prepare-dsh-profile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ test('desktop:dev does not compile the DeepSeek profile', () => {
assert.doesNotMatch(devScript, /\['run', 'prepare:dsh-profile'\]/);
assert.match(
devScript,
/Prepare resources \(parallel: monaco, version, mobile-web, flashgrep, plugin-host\)/,
/Prepare resources \(mobile-web first; then parallel: monaco, version, flashgrep, plugin-host\)/,
);
});

Expand Down
1 change: 0 additions & 1 deletion src/apps/desktop/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,6 @@ fn macos_tray_icon() -> Result<tauri::image::Image<'static>, image::ImageError>
))
}


pub fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
// Restore before showing: Win+D can leave a visible window minimized.
Expand Down
15 changes: 14 additions & 1 deletion src/crates/assembly/core/src/agentic/coordination/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10747,7 +10747,20 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet
parent_session_id
))
})?;
let context_messages = self.load_session_context_messages(&parent_session).await?;
self.load_session_context_messages(&parent_session).await?;
// The restore path above may acquire the same lock, so take the
// snapshot lock only after restoration has completed. This makes the
// final context read atomic with round-level context publication.
let _mutation_guard = self
.session_manager
.acquire_session_mutation(parent_session_id)
.await?;
let context_messages = self
.session_manager
.get_context_messages(parent_session_id)
.await?;
let context_messages =
crate::agentic::fork_agent::normalize_fork_context_messages(context_messages);
ForkAgentContextSnapshot::from_parent_session(&parent_session, context_messages)
}

Expand Down
30 changes: 13 additions & 17 deletions src/crates/assembly/core/src/agentic/execution/execution_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3962,15 +3962,12 @@ impl ExecutionEngine {
&round_result.assistant_message,
);

// Publish the assistant message and all tool results as one context
// mutation. A fork must never observe the assistant tool calls
// without their corresponding results.
let mut committed_round_messages = Vec::new();
if !round_result.assistant_message_committed {
// Update the in-memory message caches immediately so subsequent rounds see it.
if let Err(e) = self
.session_manager
.add_message(&context.session_id, round_result.assistant_message.clone())
.await
{
warn!("Failed to update assistant message in memory: {}", e);
}
committed_round_messages.push(round_result.assistant_message.clone());
}

// Add tool result messages to history
Expand All @@ -3981,15 +3978,14 @@ impl ExecutionEngine {
&context.dialog_turn_id,
tool_result_msg,
);

// Update the in-memory message caches immediately so subsequent rounds see it.
if let Err(e) = self
.session_manager
.add_message(&context.session_id, tool_result_msg.clone())
.await
{
warn!("Failed to update tool result message in memory: {}", e);
}
committed_round_messages.push(tool_result_msg.clone());
}
if let Err(e) = self
.session_manager
.add_messages(&context.session_id, committed_round_messages)
.await
{
warn!("Failed to update round messages in memory: {}", e);
}

#[cfg(feature = "agent-runtime")]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1064,8 +1064,10 @@ impl RoundExecutor {
.with_memory_citation(parsed_memory_citation)
.with_model_response_replay(model_response_replay);

// Commit the complete provider response before any tool can block on IO,
// approval, or cancellation. Tool results remain separate semantic messages.
// Publish the semantic assistant response before tool execution so
// readers can observe the active tool call while it is running. Fork
// snapshots normalize this intentionally incomplete exchange before
// sending it to a provider.
let assistant_message_committed = if let Some(session_manager) = session_manager {
session_manager
.add_message(&context.session_id, assistant_message.clone())
Expand Down
Loading
Loading