Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
136 commits
Select commit Hold shift + click to select a range
5c36ebc
Publish SDK sessions to Mattermost
simonflk-sb Sep 14, 2026
edfd67e
Mattermost: preserve uncertain sends, truthful cleanup, faithful forms
simonflk-sb Sep 14, 2026
dc9ca5c
Route an incompletely shown form to Console; admit an unasked card
simonflk-sb Sep 14, 2026
d8e890a
Name the asker, not the owner, when a card needs a mention
simonflk-sb Sep 14, 2026
433a46c
Publish SDK sessions through the Discord adapter
simonflk-sb Sep 14, 2026
92118d4
Fix the Task 4 review findings on the Discord publication seam
simonflk-sb Sep 14, 2026
5847405
Telegram SDK session parity, and disclose a card that can never be found
simonflk-sb Sep 14, 2026
d160e76
Fix the Task 7 review findings on the Telegram publication seam
simonflk-sb Sep 15, 2026
81ee89a
Answer a Telegram request by pressing it
simonflk-sb Sep 15, 2026
7c6f52c
Only offer a press where the card can be answered, and turn one down …
simonflk-sb Sep 15, 2026
e48d1e7
Tell the truth about a working reaction that would not come off
simonflk-sb Sep 15, 2026
7285215
Make the reaction evidence about the mark, not about a turn
simonflk-sb Sep 15, 2026
9561cc2
Let the publisher, not Discord, decide a card may go to the channel
simonflk-sb Sep 15, 2026
ff8a8b7
Settle a turn whose message can never be confirmed
simonflk-sb Sep 15, 2026
719ff02
Scope an unconfirmable message to its own slot
simonflk-sb Sep 15, 2026
ef7a243
Let a refusal speak only for the attempt it answers
simonflk-sb Sep 15, 2026
79c2065
Let a turn keep the grounds an earlier attempt gave it
simonflk-sb Sep 15, 2026
c09e965
Teams SDK session parity, and a connector that says what happened
simonflk-sb Sep 15, 2026
76a40dc
Teams: address a publication by what Teams confirmed, not by a guess
simonflk-sb Sep 15, 2026
2f876e6
Understand a stored message reference as a Telegram thread root
simonflk-sb Sep 15, 2026
74160f1
Make a forum's reply anchor mandatory, and drop a nudge it cannot place
simonflk-sb Sep 15, 2026
30638eb
Teams: keep a conversation's writers together, and its region with it
simonflk-sb Sep 15, 2026
4c11c55
Name a reaction's evidence by the ask, not only by the turn that made it
simonflk-sb Sep 15, 2026
1a84043
Stop reserving a handle for a destination that will not take the card
simonflk-sb Sep 15, 2026
a0eed8f
Keep a finished Telegram status in the chat as the record of the turn
simonflk-sb Sep 15, 2026
ad183d2
Keep a finished Discord status where it was published
simonflk-sb Sep 15, 2026
1da0bef
Keep a finished Teams status in the conversation, in either layout
simonflk-sb Sep 15, 2026
bdfdc8a
Give a Teams card its line breaks from blocks, not from blank lines
simonflk-sb Sep 15, 2026
6131b72
R7: say a queued prompt is queued, and stop calling an unacknowledged…
simonflk-sb Sep 15, 2026
30fa0f9
Being asked to wait is not the destination refusing the card
simonflk-sb Sep 15, 2026
5618b32
Teams: bound what a line-per-block body costs to say
simonflk-sb Sep 15, 2026
1a67611
Merge codex/sdk-server-split: durable room control follow-ups
simonflk-sb Sep 15, 2026
7bf2aea
Stop the neutral status reporting activity it is not reporting
simonflk-sb Sep 15, 2026
f2ba390
Lead a settled permission card with the answer, not the word "answered"
simonflk-sb Sep 15, 2026
5c58a2c
Stop a Telegram card printing the options its buttons already offer
simonflk-sb Sep 15, 2026
41b3246
Stop a reaction removal erasing what its holder wrote
simonflk-sb Sep 15, 2026
8cb0026
Name the reaction a turn is asking for, rather than assuming one
simonflk-sb Sep 15, 2026
085d3b9
Stop a finished turn putting back a mark another turn took off
simonflk-sb Sep 15, 2026
82ad1b9
Ask the adapters for the working mark by its name in the tests too
simonflk-sb Sep 15, 2026
1fb1ca2
Put the command in the code span as the command, not as prose
simonflk-sb Sep 15, 2026
e1223c5
Draw Telegram's fallback text with the options its buttons carried
simonflk-sb Sep 15, 2026
6586a51
Mark a queued prompt as waiting rather than as read
simonflk-sb Sep 15, 2026
6144e3d
Say so when the gateway origin only resolves on the Switch host
simonflk-sb Sep 15, 2026
5bf6da0
Say the handle and the choice are two things, not one string
simonflk-sb Sep 15, 2026
66ec402
fix(providers): put the command, not the explanation, in a request's …
simonflk-sb Sep 15, 2026
8302f62
fix(claude): keep the decision context a command approval was given
simonflk-sb Sep 15, 2026
af636fe
fix(sessions): leave no hourglass on a message nobody is waiting behind
simonflk-sb Sep 15, 2026
9b4b6b5
fix(collaboration): stop a failed card's notice showing its own tags
simonflk-sb Sep 15, 2026
d33f576
fix(collaboration): let a joining prompt ask for the mark it is sharing
simonflk-sb Sep 15, 2026
ed9de84
test(db): name the SDK session publisher in the raw-session inventory
simonflk-sb Sep 15, 2026
73058ab
test(clients): take the attachment-group deadline rather than wait on it
simonflk-sb Sep 15, 2026
dcac621
docs(bridges): describe the marks and the status a turn actually leaves
simonflk-sb Sep 15, 2026
989644a
Correct _claim_thread's account of the joiner race
simonflk-sb Sep 15, 2026
f758158
docs(bridges): correct three overclaims in the setup pages
simonflk-sb Sep 15, 2026
a2562ce
Remove the Mattermost legacy runtime renderer
simonflk-sb Sep 15, 2026
2cf4fcc
Remove the Discord legacy runtime renderer
simonflk-sb Sep 15, 2026
51a0df6
Remove Telegram's legacy runtime-state renderer
simonflk-sb Sep 15, 2026
31b96a4
Assert the whole reaction list in the deleted-message test
simonflk-sb Sep 15, 2026
8d51707
Remove the Teams legacy renderer and the shared legacy-indicator path
simonflk-sb Sep 15, 2026
83cdc9a
docs(bridges): soften two overclaims in the Discord and Slack pages
simonflk-sb Sep 15, 2026
a2ef1a4
Stop a permission option saying its own scope twice
simonflk-sb Sep 16, 2026
b2d0e12
Recognise scope-stating labels instead of reading them
simonflk-sb Sep 16, 2026
07f7627
Remove a Slack permission card once its approval is granted
simonflk-sb Sep 16, 2026
bf701f4
Owe a card's removal to the record, not to the redraw that preceded it
simonflk-sb Sep 16, 2026
dca280c
Ask whether a granted card is still there before drawing it
simonflk-sb Sep 16, 2026
8b9f3a1
Take a permission card back on any answer, not only on a yes
simonflk-sb Sep 16, 2026
1bd9f56
Leave a cancelled card where it is
simonflk-sb Sep 16, 2026
a76300f
Take an answered card back on Discord
simonflk-sb Sep 16, 2026
339740b
Take an answered card back on Telegram
simonflk-sb Sep 16, 2026
da27dd5
Take an answered card back on Mattermost
simonflk-sb Sep 16, 2026
f1a099d
Take an answered card back on Teams
simonflk-sb Sep 16, 2026
eb96bf4
Say which publications stay and which now come down
simonflk-sb Sep 16, 2026
2445b5c
Only accept evidence about the card itself when retiring one
simonflk-sb Sep 16, 2026
cab06a6
Answer a Teams permission card by pressing it
simonflk-sb Sep 16, 2026
5809497
Discord: answer a permission card by pressing a button
simonflk-sb Sep 16, 2026
86a425b
Mattermost: sign the hidden data a button carries
simonflk-sb Sep 16, 2026
56cd1ae
Collaboration: one shared door for the callbacks a platform delivers
simonflk-sb Sep 16, 2026
cd7e484
Mattermost: what a deployment needs before a press can arrive
simonflk-sb Sep 16, 2026
3624db1
Slack: stream a turn's activity into one message
simonflk-sb Sep 16, 2026
5a9a3bf
Close two gaps in the shared callback listener's lifecycle
simonflk-sb Sep 16, 2026
011513e
Draw the answer buttons on a Mattermost card
simonflk-sb Sep 16, 2026
324b8a1
Give an existing Mattermost bridge its callback address
simonflk-sb Sep 16, 2026
ea5fc8d
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 16, 2026
b0fa206
Keep a streamed turn to three blocks however long it runs
simonflk-sb Sep 16, 2026
bd150be
Say what a stream dropped on a line of its own, and keep the link whole
simonflk-sb Sep 16, 2026
1d24080
Mattermost: stop saying an option twice, and stop deleting an answere…
simonflk-sb Sep 16, 2026
66ff26e
Name the live step on the section running it, and stop escaping what …
simonflk-sb Sep 16, 2026
72f89a0
Mattermost: report a failed card with the options it could not show
simonflk-sb Sep 16, 2026
39117b3
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 16, 2026
08498d3
Slack: save the anchor when the activity is one message
simonflk-sb Sep 16, 2026
a0ff988
Discord: show a turn's tool calls privately, on request
simonflk-sb Sep 16, 2026
70a4b29
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 16, 2026
8d8c166
Discord: ask who is in a guildless channel before showing its log
simonflk-sb Sep 16, 2026
efe2d15
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 16, 2026
d981d30
Merge branch 'codex/sdk-server-split' into work/mattermost-sdk-parity
simonflk-sb Sep 16, 2026
c5d2916
Merge branch 'work/refresh-messaging-platforms' into work/mattermost-…
simonflk-sb Sep 16, 2026
7fccc39
Count the tool calls the demo's activity message carries
simonflk-sb Sep 16, 2026
bebdc9e
Teams: fold a finished turn's tool calls under its status
simonflk-sb Sep 16, 2026
aefc8ac
Mattermost: show a turn's tool calls privately, on request
simonflk-sb Sep 16, 2026
24e79c7
Telegram: fold a finished turn's tool calls into its status
simonflk-sb Sep 16, 2026
dede731
Put what the agent said into the activity disclosures
simonflk-sb Sep 16, 2026
b4eb3a4
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 16, 2026
45b4b1e
Put what the agent said into Slack's activity plan
simonflk-sb Sep 16, 2026
81bed67
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 16, 2026
d2128ff
Correct three comments that stopped being true
simonflk-sb Sep 16, 2026
a6aade8
Discord: report a failed card with the options it could not show
simonflk-sb Sep 16, 2026
822fe45
Say which fact refused an activity view, not the strongest one
simonflk-sb Sep 16, 2026
1a6e3e3
Mattermost: take a card's buttons off even where none can be offered
simonflk-sb Sep 16, 2026
bde0dd0
Discord: keep the wait when a confirming read is rate-limited
simonflk-sb Sep 16, 2026
8b5ccaa
Publish the port a Mattermost button press arrives on
simonflk-sb Sep 17, 2026
6544d46
Read a 404 for what it names, and stop calling our failures gone
simonflk-sb Sep 17, 2026
343263a
Let the bundled Mattermost call the port we just published
simonflk-sb Sep 17, 2026
e8092ec
Stop treating a bundled server as the test for where Mattermost is
simonflk-sb Sep 17, 2026
e5530f7
Keep the rest of a long remark where Slack will expand it
simonflk-sb Sep 17, 2026
f7024de
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 17, 2026
6fb4e3d
Use the card-detail helper the file already had
simonflk-sb Sep 17, 2026
8961135
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 17, 2026
d0215be
Draw a turn in one message and delete the option not to
simonflk-sb Sep 17, 2026
4108b79
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 17, 2026
d0bd901
Give a remark the room Slack turns out to have
simonflk-sb Sep 17, 2026
bd6d145
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 17, 2026
e4ecaa9
Bound a Slack turn by what the message weighs, not by the card
simonflk-sb Sep 17, 2026
da2ca94
Correct which arm of the stream ceiling measurement binds
simonflk-sb Sep 17, 2026
f670043
Draw a remark as prose with a glyph, not as a truncated line
simonflk-sb Sep 17, 2026
5ed5f3f
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 17, 2026
97adc8c
Let the Console link be the session card rather than a line under a l…
simonflk-sb Sep 17, 2026
6650d11
Merge branch 'work/mattermost-sdk-parity' into work/refresh-messaging…
simonflk-sb Sep 17, 2026
576dc9c
Slack: draw a turn as sections with the Console link inside each
simonflk-sb Sep 17, 2026
72a1c06
Mark a tool call in the platforms whose activity log is body text
simonflk-sb Sep 17, 2026
28577b6
Leave a two-section activity message as its stream drew it
simonflk-sb Sep 17, 2026
2dedde3
Preserve only a message whose stream drew the end of its turn
simonflk-sb Sep 17, 2026
90df974
style(console): format files left unformatted by the terminal removal
simonflk-sb Sep 17, 2026
2f5e276
Merge rich session activity into the messaging platform refresh
simonflk-sb Sep 17, 2026
df2db23
Join the two migration heads this merge produced
simonflk-sb Sep 17, 2026
7955f78
docs(bridges): correct the Helm account of the callback port
simonflk-sb Sep 17, 2026
11caf7a
docs(bridges): say what a blocked Mattermost press actually looks like
simonflk-sb Sep 17, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ services:
MM_DISPLAYSETTINGS_CUSTOMURLSCHEMES: switchdash
MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION: "true"
MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS: "true"
# Mattermost refuses to call a private address from an integration unless
# the host is named here, and a button on a Switch card is delivered by
# exactly that path — a POST from this container to switch-core's callback
# port on the compose network. Without it a press fails inside Mattermost
# and the person sees nothing.
MM_SERVICESETTINGS_ALLOWEDUNTRUSTEDINTERNALCONNECTIONS: switch
MM_TEAMSETTINGS_ENABLEOPENSERVER: "true"
MM_TEAMSETTINGS_TEAMMATENAMEDISPLAY: "full_name"
# Neither plugin is usable in a bundled Switch deployment: Copilot has no
Expand Down Expand Up @@ -244,6 +250,9 @@ services:
SWITCH_URL: http://switch:8000
MATTERMOST_URL: http://mattermost:8065
MATTERMOST_URL_FOR_SWITCH: http://mattermost:8065
# The route back: where the Mattermost container reaches switch-core's
# callback port, for the button presses Mattermost delivers by HTTP.
MATTERMOST_CALLBACK_BASE_URL: http://switch:8081
MATTERMOST_ADMIN_USER: ${MATTERMOST_ADMIN_USER}
MATTERMOST_ADMIN_PASSWORD: ${MATTERMOST_ADMIN_PASSWORD}
MATTERMOST_TEAM_NAME: ${MATTERMOST_TEAM_NAME}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,7 @@ describe('ClaudeAdapter approvals', () => {
expect(opened.requestType).toBe('command_execution_approval');
expect(opened.turnId).toBe('turn-1');
expect(opened.title).toBe('Claude wants to run echo hi');
expect(opened.detail).toBe('echo hi');
expect(opened.options.map((option) => option.decision)).toEqual([
'accept',
'acceptForSession',
Expand All @@ -581,6 +582,98 @@ describe('ClaudeAdapter approvals', () => {
expect(resolved.decision).toBe('accept');
});

it('puts the command in detail and the description in the title', async () => {
const { sdk, adapter, recorder } = await startSession();
await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' });
const controller = new AbortController();
void sdk.canUseTool()(
'Bash',
{ command: 'pwd', description: 'Print working directory' },
toolOptions(controller.signal)
);

const opened = await recorder.waitFor('request.opened', () => true, 1_000);
expect(opened.title).toBe('Print working directory');
expect(opened.detail).toBe('pwd');
});

it('names the tool when a command arrives with nothing to describe it', async () => {
const { sdk, adapter, recorder } = await startSession();
await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' });
const controller = new AbortController();
void sdk.canUseTool()('Bash', { command: 'pwd' }, toolOptions(controller.signal));

const opened = await recorder.waitFor('request.opened', () => true, 1_000);
expect(opened.title).toBe('Run a Bash command');
expect(opened.detail).toBe('pwd');
});

it('keeps the description when the host also supplied a title', async () => {
const { sdk, adapter, recorder } = await startSession();
await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' });
const controller = new AbortController();
void sdk.canUseTool()(
'Bash',
{ command: 'npm run deploy' },
toolOptions(controller.signal, {
title: 'Run deployment',
description: 'Uses production credentials.',
})
);

const opened = await recorder.waitFor('request.opened', () => true, 1_000);
expect(opened.title).toBe('Run deployment — Uses production credentials.');
expect(opened.detail).toBe('npm run deploy');
});

it('does not cut the decision context before anything has budgeted it', async () => {
const { sdk, adapter, recorder } = await startSession();
await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' });
const controller = new AbortController();
const reason = `${'This runs against the configured workspace. '.repeat(6)}It writes to production.`;
void sdk.canUseTool()(
'Bash',
{ command: 'npm run deploy' },
toolOptions(controller.signal, { description: reason })
);

const opened = await recorder.waitFor('request.opened', () => true, 1_000);
expect(reason.length).toBeGreaterThan(160);
expect(opened.title).toBe(reason);
});

it('says a title once when the host sends it as both title and description', async () => {
const { sdk, adapter, recorder } = await startSession();
await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' });
const controller = new AbortController();
void sdk.canUseTool()(
'Bash',
{ command: 'pwd' },
toolOptions(controller.signal, {
title: 'Print working directory',
description: 'Print working directory',
})
);

const opened = await recorder.waitFor('request.opened', () => true, 1_000);
expect(opened.title).toBe('Print working directory');
});

it('leaves a tool that is not a command with its description as the detail', async () => {
const { sdk, adapter, recorder } = await startSession();
await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' });
const controller = new AbortController();
void sdk.canUseTool()(
'Write',
{ file_path: '/work/notes.md' },
toolOptions(controller.signal, { description: 'Create the notes file' })
);

const opened = await recorder.waitFor('request.opened', () => true, 1_000);
expect(opened.title).toBe('Write /work/notes.md');
expect(opened.detail).toBe('Create the notes file');
});

it('rescopes the CLI suggestions to the session on acceptForSession', async () => {
const { sdk, adapter, recorder } = await startSession();
await adapter.sendTurn({ sessionId: SESSION, turnId: 'turn-1', text: 'go' });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
UserInputQuestion,
} from '../events';
import {
approvalContent,
isRecord,
itemTypeForTool,
outcomeForResult,
Expand Down Expand Up @@ -977,8 +978,7 @@ export class ClaudeAdapter implements ProviderAdapter {
turnId,
requestId,
requestType: requestTypeForTool(toolName),
title: options.title ?? toolTitle(toolName, toolInput),
...(options.description ? { detail: options.description } : {}),
...approvalContent(toolName, toolInput, options.title, options.description),
options: APPROVAL_OPTIONS,
raw: { source: 'claude', payload: { toolName, toolInput } },
});
Expand Down
37 changes: 37 additions & 0 deletions console/packages/agent-providers/src/claude/claude-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,43 @@ export function toolTitle(toolName: string, input: Record<string, unknown>): str
return toolName;
}

/**
* Which part of a permission request is the command and which is the prose.
*
* A reader sees `detail` set apart — a code span where the surface has one — so
* it has to be the literal thing being approved, not a second sentence about
* it. For a command tool the command is in the input and the summary is the
* prose, which is the opposite of how they read on an activity item.
*
* Both halves of the prose are kept when the host sends both: a title says what
* the command is for and a description often says what it will cost, and
* someone deciding needs the second one most. Nothing is cut here — a consumer
* that cuts also records that it did, and answers for whether what survived is
* still enough to decide on. Cutting first hides that judgement from it.
*/
export function approvalContent(
toolName: string,
input: Record<string, unknown>,
title: string | undefined,
description: string | undefined
): { title: string; detail?: string } {
if (COMMAND_TOOLS.has(toolName)) {
const command = stringField(input, 'command');
if (command) {
const summary = description ?? stringField(input, 'description');
const prose = [...new Set([title, summary].filter((part) => part !== undefined))];
return {
title: prose.length > 0 ? prose.join(' — ') : `Run a ${toolName} command`,
detail: command,
};
}
}
return {
title: title ?? toolTitle(toolName, input),
...(description ? { detail: description } : {}),
};
}

/**
* The CLI stamps a user abort on the result: `aborted_streaming` when the
* interrupt landed mid-stream, `aborted_tools` when it landed in a tool call.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,58 @@ describe('CodexAdapter', () => {
expect(eventsOf(events, 'request.resolved')[0]).toMatchObject({ decision: 'decline' });
});

it('puts the command in detail and the reason for asking in the title', async () => {
const { adapter, server, events } = await start('approval-required');
server.replyAlways('turn/start', () => ({ turn: { id: 'native-a', status: 'inProgress' } }));
await adapter.sendTurn({ sessionId: 'session-1', turnId: 'caller-1', text: 'run it' });
server.notify('turn/started', turnNotification('native-a', 'inProgress'));

server.send({
id: 92,
method: 'item/commandExecution/requestApproval',
params: {
threadId: THREAD,
turnId: 'native-a',
itemId: 'exec-1',
command: 'rm -rf build',
cwd: '/work',
reason: 'Clearing stale build output',
availableDecisions: ['accept'],
},
});
await vi.waitFor(() => expect(eventsOf(events, 'request.opened')).toHaveLength(1));
expect(eventsOf(events, 'request.opened')[0]).toMatchObject({
title: 'Clearing stale build output',
detail: 'rm -rf build',
});
});

it('keeps the working directory visible when codex gives no reason', async () => {
const { adapter, server, events } = await start('approval-required');
server.replyAlways('turn/start', () => ({ turn: { id: 'native-a', status: 'inProgress' } }));
await adapter.sendTurn({ sessionId: 'session-1', turnId: 'caller-1', text: 'run it' });
server.notify('turn/started', turnNotification('native-a', 'inProgress'));

server.send({
id: 93,
method: 'item/commandExecution/requestApproval',
params: {
threadId: THREAD,
turnId: 'native-a',
itemId: 'exec-1',
command: 'echo hi',
cwd: '/work',
reason: null,
availableDecisions: ['accept'],
},
});
await vi.waitFor(() => expect(eventsOf(events, 'request.opened')).toHaveLength(1));
expect(eventsOf(events, 'request.opened')[0]).toMatchObject({
title: 'Run a command in /work',
detail: 'echo hi',
});
});

it('surfaces a question and answers it with the codex answer shape', async () => {
const { adapter, server, events } = await start();
server.replyAlways('turn/start', () => ({ turn: { id: 'native-a', status: 'inProgress' } }));
Expand Down
19 changes: 17 additions & 2 deletions console/packages/agent-providers/src/codex/codex-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ function advertisedDecisions(raw: CodexCommandExecutionApprovalParams): Approval
return decisions;
}

/**
* Which part of a command approval is the command and which is the prose.
*
* A reader sees `detail` set apart — a code span where the surface has one — so
* the command belongs there and Codex's reason for asking belongs in the title.
* With no command there is nothing to set apart and the prose carries the card.
*/
function commandApprovalContent(payload: CodexCommandExecutionApprovalParams): {
title: string;
detail?: string;
} {
const where = payload.cwd ? `Run a command in ${payload.cwd}` : 'Run a command';
if (!payload.command) return { title: payload.reason ?? where };
return { title: payload.reason ?? where, detail: payload.command };
}

function toCodexInput(
text: string,
attachments: ProviderSendTurnInput['attachments']
Expand Down Expand Up @@ -674,8 +690,7 @@ export class CodexAdapter implements ProviderAdapter {
return this.openApproval(state, {
turnId: payload.turnId,
requestType: 'command_execution_approval',
title: payload.command ?? 'Run a command',
detail: payload.reason ?? payload.cwd ?? undefined,
...commandApprovalContent(payload),
options: approvalOptions(advertisedDecisions(payload)),
respond: (decision) => ({ decision }),
});
Expand Down
6 changes: 6 additions & 0 deletions console/packages/agent-providers/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,13 @@ export type ProviderRuntimeEvent = EventBase &
turnId: string;
requestId: string;
requestType: RequestType;
/** Prose: what is being asked, in a sentence a reader can skim. */
title: string;
/**
* The literal thing being approved — a command, a path — and nothing
* else. Consumers set it apart from the prose, in a code span where the
* surface has one, so a sentence here reads as something to type.
*/
detail?: string;
options: ApprovalOption[];
}
Expand Down
27 changes: 9 additions & 18 deletions core/switch_core/bridges/agent/protocol/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1501,24 +1501,15 @@ async def set_runtime_state(
"""Record and broadcast an agent's runtime state in a room.

Persists the latest state (so it is queryable via `!status`) and emits
a `com.switch.agent.runtime_state` room event the collaboration bridge
picks up to surface the state on the bridged channel. Reported by the
Switch Console connector as its managed session transitions.

`thread_id` (the triggering message's thread, when it was in one) rides
the event so the bridge can surface the state in that thread. It is
transient routing only — it is never persisted as part of the state.

`detail` is a short activity line for the running turn (e.g. "Editing
foo.py"); like `thread_id` it is transient and rides the event only —
the bridge surfaces it in place on the live working message.

`anchor_event_id` is the latest message the reporting connector has
actually handed to the agent's session. The bridge repositions the
indicator when it changes, so position follows what the agent has
genuinely been given rather than what merely arrived in the room. Also
transient routing — reported on every refresh, and only a change moves
anything.
a `com.switch.agent.runtime_state` room event for protocol clients that
watch it. Reported by the Switch Console connector as its managed
session transitions.

What a bridged channel shows of a running turn is the SDK session
publication, not this: no collaboration adapter renders the event any
more. `thread_id`, `detail` and `anchor_event_id` still ride it as
transient routing — never persisted as part of the state — and describe
where the turn is happening for a client that wants to draw it.

The `switchdash://` deeplink is rewritten to a gateway HTTP redirect for
platforms that linkify only http(s) (Discord, Telegram), so the "Open in
Expand Down
Loading
Loading