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
16 changes: 14 additions & 2 deletions packages/code-sdk-python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,26 @@ async def main() -> None:
# Same Session, next Turn.
turn = await thread.run("Now list its top-level directories.")

# Persist thread.id and continue later.
later = client.resume_thread(thread.id)
# Persist thread.id and continue later, in the same workspace.
later = client.resume_thread(thread.id, ThreadOptions(cwd="."))
print((await later.run("What did I ask first?")).final_response)


asyncio.run(main())
```

### Resuming a Session

A Session belongs to the workspace it was created in. Pass the same `cwd` to `resume_thread()`;
when `cwd` is omitted, the current working directory is used. A different workspace fails with
`MCodeRunError` ("Session ... belongs to workspace ..."), and retrying does not help.

```python
thread = client.start_thread(ThreadOptions(cwd="/path/to/project"))
await thread.run("...")
later = client.resume_thread(thread.id, ThreadOptions(cwd="/path/to/project"))
```

### Streaming

```python
Expand Down
7 changes: 6 additions & 1 deletion packages/code-sdk-python/src/minimax_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ def start_thread(self, options: ThreadOptions | None = None) -> Thread:
return Thread(self._host, options, None)

def resume_thread(self, thread_id: str, options: ThreadOptions | None = None) -> Thread:
"""Continues an existing Session, for example one persisted from ``thread.id``."""
"""Continues an existing Session, for example one persisted from ``thread.id``.

Pass the same ``ThreadOptions.cwd`` the Session was created with. When ``cwd`` is omitted
the current working directory is used, and the CLI rejects a Session whose workspace
differs (``MCodeRunError`` with "belongs to workspace ...").
"""
if not isinstance(thread_id, str) or not thread_id.strip():
raise MCodeInvocationError("resume_thread requires a non-empty Session id.")
options = options or ThreadOptions()
Expand Down
2 changes: 2 additions & 0 deletions packages/code-sdk-python/src/minimax_code/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def start_thread(self, options: ThreadOptions | None = None) -> SyncThread:
return SyncThread(self._loop, self._client.start_thread(options))

def resume_thread(self, thread_id: str, options: ThreadOptions | None = None) -> SyncThread:
"""Continues an existing Session; pass the ``cwd`` it was created with (see
``MCode.resume_thread``)."""
return SyncThread(self._loop, self._client.resume_thread(thread_id, options))

def close(self) -> None:
Expand Down
6 changes: 6 additions & 0 deletions packages/tui/src/headless/error-presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function formatTuiExecFailure(error: TuiExecError): string {
}
if (error.kind === 'runtime') {
if (isOutputLimitExhaustedFailure(error.message)) return OUTPUT_LIMIT_EXHAUSTED_GUIDANCE;
if (!error.retryable) return asSentence(`The run failed: ${tuiErrorDiagnostic(error)}`);
return `The run failed: ${tuiErrorDiagnostic(error)}. Retry after the connection recovers.`;
}
return `MCode encountered an internal error: ${tuiErrorDiagnostic(error)}. Retry; if it keeps happening, report it through an available support channel.`;
Expand All @@ -43,5 +44,10 @@ export function formatTuiExecResultFailure(result: ExecResult): string {
}
if (isOutputLimitExhaustedFailure(result.error?.message)) return OUTPUT_LIMIT_EXHAUSTED_GUIDANCE;
const reason = result.error?.message ? `: ${tuiErrorDiagnostic(result.error.message)}` : '';
if (result.error?.retryable === false) return asSentence(`The run failed${reason}`);
return `The run failed${reason}. Retry after the connection recovers.`;
}

function asSentence(value: string): string {
return /[.!?…]$/u.test(value) ? value : `${value}.`;
}
7 changes: 6 additions & 1 deletion packages/tui/src/headless/exit-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,16 @@ export type TuiExecErrorKind = keyof Pick<
'invocation' | 'config' | 'runtime' | 'internal' | 'cancelled' | 'brokenPipe'
>;

export interface TuiExecErrorOptions extends ErrorOptions {
/** Defaults to true for runtime errors. Set false when retrying cannot succeed. */
readonly retryable?: boolean;
}

export class TuiExecError extends TuiFailure {
constructor(
public readonly kind: TuiExecErrorKind,
message: string,
options?: ErrorOptions,
options?: TuiExecErrorOptions,
) {
super(kind, message, {
code: `exec.${kind}`,
Expand Down
22 changes: 15 additions & 7 deletions packages/tui/src/headless/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,8 @@ async function resolveSession(
if (!session) {
throw new TuiExecError(
"runtime",
`No active Session exists for workspace ${input.workspaceDir}.`,
`No active Session exists for workspace ${input.workspaceDir}; omit --continue to start a new Session.`,
{ retryable: false },
);
}
assertSessionUsable(session, input.workspaceDir);
Expand Down Expand Up @@ -946,16 +947,23 @@ function assertSessionUsable(session: TuiSession, workspaceDir: string): void {
if (session.archived) {
throw new TuiExecError(
"runtime",
`Session is archived: ${session.sessionId}`,
`Session ${session.sessionId} is archived; unarchive it in MCode or start a new Session.`,
{ retryable: false },
);
}
if (
!session.workspaceDir ||
!sameWorkspace(session.workspaceDir, workspaceDir)
) {
if (!session.workspaceDir) {
throw new TuiExecError(
"runtime",
`Session workspace does not match --cwd: ${session.sessionId}`,
`Session ${session.sessionId} has no recorded workspace and cannot be continued.`,
{ retryable: false },
);
}
if (!sameWorkspace(session.workspaceDir, workspaceDir)) {
// Retrying cannot succeed: a Session only continues in the workspace it was created in.
throw new TuiExecError(
"runtime",
`Session ${session.sessionId} belongs to workspace ${session.workspaceDir}, not --cwd ${workspaceDir}; rerun with --cwd ${session.workspaceDir}.`,
{ retryable: false },
);
}
}
Expand Down
53 changes: 53 additions & 0 deletions packages/tui/test/unit/headless-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { join } from 'node:path';

import { describe, expect, it, vi } from 'vitest';

import { formatTuiExecResultFailure } from '../../src/headless/error-presentation.js';
import { runTuiExec } from '../../src/headless/runner.js';
import type { TuiMessage, TuiStreamEvent } from '../../src/runtime/stream-events.js';

Expand Down Expand Up @@ -1316,3 +1317,55 @@ describe('runTuiExec', () => {
expect(runtime.sendMessage).not.toHaveBeenCalled();
});
});

describe('runTuiExec Session guidance', () => {
async function run(input: { sessionId?: string; continueSession?: boolean }, runtime: ReturnType<typeof runtimeWith>) {
const stderr = vi.fn();
const code = await runTuiExec(
{ prompt: 'Fix it', workspaceDir: '/tmp/workspace', version: '0.1.0', ...input },
{ runtime, stdout: vi.fn(), stderr, shutdown: async () => undefined },
);
return { code, stderr: stderr.mock.calls.map(([line]) => line).join('') };
}

it('names the Session workspace on a --cwd mismatch instead of suggesting a retry', async () => {
const runtime = runtimeWith();
runtime.getSession.mockResolvedValue({ sessionId: 'session-1', workspaceDir: '/tmp/other', archived: false } as never);

const { code, stderr } = await run({ sessionId: 'session-1' }, runtime);

expect(code).toBe(4);
expect(stderr).toBe(
'mcode exec failed: The run failed: Session session-1 belongs to workspace /tmp/other, not --cwd /tmp/workspace; rerun with --cwd /tmp/other.\n',
);
expect(runtime.sendMessage).not.toHaveBeenCalled();
});

it('does not suggest a retry for archived Sessions or a missing --continue target', async () => {
const archived = runtimeWith();
archived.getSession.mockResolvedValue({ sessionId: 'session-1', workspaceDir: '/tmp/workspace', archived: true } as never);
const missing = runtimeWith();

const results = [await run({ sessionId: 'session-1' }, archived), await run({ continueSession: true }, missing)];

expect(results.map((result) => result.code)).toEqual([4, 4]);
expect(results[0]?.stderr).toBe(
'mcode exec failed: The run failed: Session session-1 is archived; unarchive it in MCode or start a new Session.\n',
);
expect(results[1]?.stderr).toBe(
'mcode exec failed: The run failed: No active Session exists for workspace /tmp/workspace; omit --continue to start a new Session.\n',
);
for (const result of results) expect(result.stderr).not.toContain('Retry');
});

it('keeps the retry hint only for retryable failed results', () => {
const failed = (retryable: boolean | undefined) => formatTuiExecResultFailure({
status: 'failed',
error: { category: 'runtime', message: 'Upstream disconnected', ...(retryable === undefined ? {} : { retryable }) },
} as never);

expect(failed(true)).toBe('The run failed: Upstream disconnected. Retry after the connection recovers.');
expect(failed(undefined)).toBe('The run failed: Upstream disconnected. Retry after the connection recovers.');
expect(failed(false)).toBe('The run failed: Upstream disconnected.');
});
});
Loading